"use client"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { PROPERTY_TYPES, PROPERTY_TYPE_LABEL, STATUS_LABEL } from "@/lib/listings/types"; import { REGIONS } from "@/lib/normalize"; import { fmtPrice } from "@/lib/format"; import { Notice } from "@/components/ui"; import { MapView } from "@/components/map-view"; import { IconClose } from "@/components/icons"; type Media = { id: string; url: string; large: string; altText: string; position: number }; export type WizardData = { id: string; slug: string; status: string; version: number; wizardStep: number; attestedAt: string | null; moderationNote: string | null; listingType: "SALE" | "RENT"; sourceType: "DIRECT_OWNER" | "DIRECT_AGENT"; propertyType: string; address: string | null; unit: string | null; city: string | null; region: string | null; postalCode: string | null; lat: number | null; lng: number | null; roleUnitId: string | null; privacyLevel: string; bedrooms: number | null; bathrooms: number | null; areaSqft: number | null; lotSqft: number | null; yearBuilt: number | null; title: string | null; description: string | null; priceAmount: number | null; availabilityDate: string | null; furnished: boolean | null; petsPolicy: string | null; smokingPolicy: string | null; parking: string | null; leaseTerms: string | null; inclusions: string[]; features: Record; media: Media[] }; const STEPS = ["Type", "Adresse", "Caractéristiques", "Conditions", "Photos", "Description", "Vérification", "Aperçu"]; const INCLUSIONS_RENT = ["Chauffage", "Électricité", "Eau chaude", "Internet", "Stationnement", "Laveuse-sécheuse", "Réfrigérateur", "Cuisinière", "Lave-vaisselle", "Climatisation", "Meublé", "Balcon", "Rangement", "Ascenseur", "Gym", "Piscine", "Accessible (mobilité réduite)"]; const INCLUSIONS_SALE = ["Garage", "Piscine", "Foyer", "Climatisation centrale", "Thermopompe", "Sous-sol aménagé", "Bord de l'eau", "Vue", "Terrain boisé", "Rénové récemment", "Accessible (mobilité réduite)", "Électroménagers inclus"]; export function Wizard({ initial }: { initial: WizardData }) { const [d, setD] = useState(initial); const [step, setStep] = useState(Math.min(initial.wizardStep, STEPS.length - 1)); const [saving, setSaving] = useState<"idle" | "saving" | "saved" | "error">("idle"); const [err, setErr] = useState(null); const [problems, setProblems] = useState([]); const router = useRouter(); const dirty = useRef>({}); const timer = useRef | null>(null); const rent = d.listingType === "RENT"; const set = (patch: Partial) => { setD((x) => ({ ...x, ...patch })); Object.assign(dirty.current, patch); setSaving("saving"); if (timer.current) clearTimeout(timer.current); timer.current = setTimeout(save, 700); }; const save = useCallback(async (extra: Partial = {}) => { const patch = { ...dirty.current, ...extra }; dirty.current = {}; if (!Object.keys(patch).length) { setSaving("saved"); return true; } const body: Record = { version: d.version }; for (const k of ["listingType", "sourceType", "propertyType", "address", "unit", "city", "region", "postalCode", "lat", "lng", "roleUnitId", "privacyLevel", "bedrooms", "bathrooms", "areaSqft", "lotSqft", "yearBuilt", "title", "description", "priceAmount", "availabilityDate", "furnished", "petsPolicy", "smokingPolicy", "parking", "leaseTerms", "inclusions", "features", "wizardStep"] as const) if (k in patch) body[k] = (patch as Record)[k]; const r = await fetch(`/api/listings/${d.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify(body) }); const j = await r.json(); if (!r.ok) { setSaving("error"); setErr(j.error?.code === "conflict" ? "Cette annonce a été modifiée ailleurs : rechargez la page." : j.error?.issues?.map((i: { path: string; message: string }) => `${i.path} : ${i.message}`).join(" · ") ?? j.error?.message ?? "Erreur d'enregistrement"); return false; } setD((x) => ({ ...x, version: j.listing.version, status: j.listing.status })); setSaving("saved"); setErr(null); return true; }, [d.id, d.version]); useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []); const go = async (n: number) => { if (timer.current) clearTimeout(timer.current); const ok = await save({ wizardStep: n }); if (ok) { setStep(n); window.scrollTo({ top: 0, behavior: "smooth" }); } }; // ── Adresse : recherche dans le rôle d'évaluation (3,7 M d'unités) pour géocoder sans service externe const [adjust, setAdjust] = useState(false); const [addrQ, setAddrQ] = useState(""); const [addrSug, setAddrSug] = useState<{ id: string; address: string; unit: string | null; city: string; type_prop: string; year_built: number | null; floor_area_m2: number | null }[]>([]); useEffect(() => { if (addrQ.trim().length < 4) { setAddrSug([]); return; } const t = setTimeout(() => fetch(`/api/estimate?q=${encodeURIComponent(addrQ)}`).then((r) => r.json()).then((j) => setAddrSug(j.units ?? [])).catch(() => {}), 200); return () => clearTimeout(t); }, [addrQ]); const pickUnit = async (u: (typeof addrSug)[number]) => { const r = await fetch(`/api/estimate?id=${encodeURIComponent(u.id)}`).then((r) => r.json()).catch(() => null); const lat = r?.subject?.lat ?? null, lng = r?.subject?.lng ?? null; set({ address: u.address, unit: u.unit, city: u.city, lat, lng, roleUnitId: u.id, yearBuilt: d.yearBuilt ?? u.year_built ?? null, areaSqft: d.areaSqft ?? (u.floor_area_m2 ? Math.round(u.floor_area_m2 * 10.7639) : null) }); setAddrSug([]); setAddrQ(""); }; // ── Photos const [uploading, setUploading] = useState([]); const fileRef = useRef(null); const upload = async (files: FileList | File[]) => { for (const f of Array.from(files)) { setUploading((u) => [...u, f.name]); try { const pre = await fetch(`/api/listings/${d.id}/media/presign`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ fileName: f.name, mimeType: f.type || "application/octet-stream", bytes: f.size }) }); const pj = await pre.json(); if (!pre.ok) throw new Error(pj.error?.message ?? "Erreur"); const up = await fetch(pj.uploadUrl, { method: "PUT", headers: { "content-type": f.type || "application/octet-stream" }, body: f }); const uj = await up.json(); if (!up.ok) throw new Error(uj.error?.message ?? "Photo refusée"); setD((x) => ({ ...x, media: [...x.media, { id: uj.mediaId, url: uj.url.replace("/800.webp", "/320.webp"), large: uj.url, altText: "", position: x.media.length }] })); } catch (e) { setErr(`${f.name} : ${(e as Error).message}`); } finally { setUploading((u) => u.filter((n) => n !== f.name)); } } }; const removeMedia = async (id: string) => { await fetch(`/api/listings/${d.id}/media/${id}`, { method: "DELETE" }); setD((x) => ({ ...x, media: x.media.filter((m) => m.id !== id) })); }; const moveMedia = async (i: number, dir: -1 | 1) => { const arr = [...d.media]; const j = i + dir; if (j < 0 || j >= arr.length) return; [arr[i], arr[j]] = [arr[j], arr[i]]; setD((x) => ({ ...x, media: arr })); await fetch(`/api/listings/${d.id}/media/confirm`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ order: arr.map((m) => m.id) }) }); }; const caption = async (id: string, alt: string) => { setD((x) => ({ ...x, media: x.media.map((m) => m.id === id ? { ...m, altText: alt } : m) })); await fetch(`/api/listings/${d.id}/media/confirm`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ captions: { [id]: alt } }) }); }; const attest = async () => { const r = await fetch(`/api/listings/${d.id}/attest`, { method: "POST" }); if (r.ok) setD((x) => ({ ...x, attestedAt: new Date().toISOString() })); }; const publish = async () => { if (timer.current) clearTimeout(timer.current); if (!(await save())) return; setProblems([]); const r = await fetch(`/api/listings/${d.id}/publish`, { method: "POST" }); const j = await r.json(); if (r.status === 422) { setProblems(j.problems); return; } if (!r.ok) { setErr(j.error?.message ?? "Erreur"); return; } setD((x) => ({ ...x, status: j.status })); router.refresh(); }; const F = useMemo(() => ({ num: (k: keyof WizardData, label: string, opts: { step?: number; min?: number; max?: number; suffix?: string; help?: string } = {}) => ( ), text: (k: keyof WizardData, label: string, opts: { placeholder?: string; max?: number; help?: string } = {}) => ( ), // eslint-disable-next-line react-hooks/exhaustive-deps }), [d]); const cardIndicator = {saving === "saving" ? "Enregistrement…" : saving === "saved" ? "Brouillon enregistré" : saving === "error" ? "Non enregistré" : ""}; const incl = rent ? INCLUSIONS_RENT : INCLUSIONS_SALE; const isLand = d.propertyType === "terrain", isCom = d.propertyType === "commercial"; return (
← Mes annonces

{d.status === "DRAFT" ? "Publier une propriété" : `Modifier l'annonce · ${STATUS_LABEL[d.status]}`}

{cardIndicator}
    {STEPS.map((s, i) =>
  1. )}
{err &&
{err}
} {d.moderationNote && ["PENDING_REVIEW", "REJECTED"].includes(d.status) &&
Vérification : {d.moderationNote}. {d.status === "REJECTED" ? "Corrigez l'annonce puis republiez ; vous pouvez contester via Signaler un problème → « autre »." : "Un modérateur examinera l'annonce ; vous serez avisé par courriel."}
}
{step === 0 && (

Je souhaite

{(["RENT", "SALE"] as const).map((t) => )}

Type de bien

{PROPERTY_TYPES.map((t) => )}

Je publie en tant que

{([["DIRECT_OWNER", "Propriétaire", "Je suis propriétaire (ou mandaté par écrit) du bien."], ["DIRECT_AGENT", "Représentant", "Je représente le propriétaire (courtier, gestionnaire). La mention « professionnel vérifié » n'apparaît qu'après vérification par Toveli."]] as const).map(([v, l, h]) => )}
)} {step === 1 && (
{addrSug.length > 0 &&
    {addrSug.map((u) =>
  • )}
}
{F.text("address", "Adresse civique", { placeholder: "1234 rue des Érables", max: 200 })}{F.text("unit", "Appartement / unité", { placeholder: "4", max: 20 })}{F.text("city", "Municipalité", { max: 100 })} {F.text("postalCode", "Code postal", { placeholder: "G1R 2L3", max: 7 })}

Position sur la carte {d.lat == null && — requise : choisissez une adresse ci-dessus ou activez l'ajustement}

{ if (adjust) set({ lat: Math.round(((b[1] + b[3]) / 2) * 1e5) / 1e5, lng: Math.round(((b[0] + b[2]) / 2) * 1e5) / 1e5 }); }} />{adjust && 📍}
{F.num("lat", "Latitude", { step: 0.00001, min: 44, max: 63 })}{F.num("lng", "Longitude", { step: 0.00001, min: -80, max: -57 })}
)} {step === 2 && (
{!isLand && !isCom && F.num("bedrooms", "Chambres", { step: 1, min: 0, max: 30 })}{!isLand && !isCom && F.num("bathrooms", "Salles de bain", { step: 0.5, min: 0, max: 20 })} {!isLand && F.num("areaSqft", "Superficie habitable", { suffix: "pi²", min: 50, help: "Mesure intérieure ; 1 m² = 10,76 pi²" })} {(!rent || isLand) && F.num("lotSqft", "Superficie du terrain", { suffix: "pi²", min: 100 })} {!isLand && F.num("yearBuilt", "Année de construction", { min: 1600, max: 2030 })} {!isLand && F.text("parking", "Stationnement", { placeholder: "Garage simple + 2 places extérieures", max: 120 })} {!isLand && } {!isLand && }

{rent ? "Inclusions et commodités" : "Caractéristiques"}

{incl.map((x) => )}
)} {step === 3 && (
{F.num("priceAmount", rent ? "Loyer mensuel demandé" : "Prix demandé", { suffix: "$ CAD", step: rent ? 5 : 1000, min: 0, help: rent ? "Loyer de base par mois, avant frais facultatifs." : "Prix affiché publiquement ; l'estimation Toveli sera montrée à titre indicatif." })} {rent && } {rent && } {rent && } {rent && } {rent && } {!rent && } {!rent && } {!rent && d.propertyType === "condo" && }
{rent ? Au Québec, le locateur ne peut exiger aucun dépôt de garantie ni paiement d'avance autre que le premier mois de loyer, et doit utiliser le bail obligatoire du Tribunal administratif du logement (TAL). La clause G du bail indique le loyer le plus bas payé au cours des 12 derniers mois. Les critères discriminatoires sont interdits (Charte québécoise). : Le vendeur doit déclarer les vices et défauts connus (déclaration du vendeur). Toveli n'est pas un courtier : la transaction se conclut entre les parties, idéalement avec notaire et inspection. Les droits de mutation sont à la charge de l'acheteur.}
)} {step === 4 && (
e.preventDefault()} onDrop={(e) => { e.preventDefault(); upload(e.dataTransfer.files); }} className="border-2 border-dashed border-line rounded-card p-6 text-center">

Glissez vos photos ici

JPEG, PNG, WebP ou HEIC · 25 Mo max par photo · 30 photos max · les données EXIF/GPS sont retirées automatiquement

e.target.files && upload(e.target.files)} />
{uploading.length > 0 &&

Traitement : {uploading.join(", ")}…

}
{d.media.length > 0 &&
    {d.media.map((m, i) =>
  • {m.altText}{i === 0 && Photo principale}
    caption(m.id, e.target.value)} placeholder="Légende" maxLength={140} className="flex-1 min-w-0 text-xs border-b border-line outline-none py-0.5" />
  • )}
} En téléversant des photos, vous confirmez en détenir les droits et consentez à leur publication sur Toveli. Pas de personnes identifiables ni de documents personnels sur les photos.
)} {step === 5 && (
{F.text("title", "Titre de l'annonce", { placeholder: rent ? "Grand 4½ lumineux près du métro, chauffé" : "Maison familiale rénovée, grand terrain, quartier paisible", max: 120, help: "8 à 120 caractères. Pas de majuscules excessives." })}